Skip to content

perf(kernel): import pyarrow on the first result instead of in connect() (stacked on #1049) - #1053

Open
vuanhphung wants to merge 14 commits into
mainfrom
perf/rd-connect-lazy-pyarrow
Open

vuanhphung wants to merge 14 commits into
mainfrom
perf/rd-connect-lazy-pyarrow

Conversation

@vuanhphung

@vuanhphung vuanhphung commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

What type of PR is this?

  • Other (performance)

Description

Stacked on #1049 (lazy pandas). Its two commits show in this diff until it merges, and without it pandas would still import numpy in connect().

A kernel connect() imports pyarrow, and with it numpy. client.py, utils.py and result_set.py import pyarrow at module load as an optional dependency. The kernel client imports its result set and type mapping, which need pyarrow, at module load too. Besides the import itself (~70 ms here), numpy starts OpenBLAS's thread pool: one thread per core, busy-waiting for a while after start. A bare import numpy burns 0.33 CPU-seconds in the following idle second on 8 cores (0.00 with OPENBLAS_NUM_THREADS=1). During a 200-thread connect burst that spin was half the process's CPU and took ~65 ms of connect() p50 (measured diagnostically with OPENBLAS_NUM_THREADS=1). With several processes starting at once it competes with every connection being opened.

The three modules now get pyarrow from optional_pyarrow(). It returns the real module if pyarrow is already imported, None if it isn't installed, and otherwise a stand-in that imports pyarrow on first attribute access. That first access also rebinds pyarrow in those modules to the real module, so reads on the query path are plain module attribute lookups (through the stand-in a read costs 63 ns instead of 9 ns, 12 reads per query). Installation is decided with importlib.util.find_spec, so every pyarrow is None check, and the warning client.py logs when pyarrow is missing, behave as before. The module attributes keep their names, and type checkers still see a plain import pyarrow. The kernel client imports its result set and type mapping at the first result and the first bound parameter. The Thrift and SEA backends are unchanged and still import pyarrow when they load. A kernel connection now imports pyarrow and numpy at its first execute(), and pandas (#1049) at its first row conversion. One edge case changes: pyarrow installed but failing to import (a broken install, e.g. a numpy ABI mismatch). Before, every module treated it as absent. Now client, utils and result_set treat it as available, and the first attribute use raises an AttributeError that carries the ImportError, so hasattr and getattr(..., default) still work. The Thrift and SEA backends still import it when they load and treat it as missing, so the Thrift result format is unchanged. In that case the "pyarrow is not installed" warning isn't logged, and connection telemetry reports enable_arrow=True. Making those agree would mean importing pyarrow in connect(), which this change exists to avoid.

How is this tested?

  • Unit tests
  • Manually

New tests, in fresh interpreters with a stand-in kernel wheel: importing databricks.sql, client, utils, result_set and the kernel client loads none of pyarrow, numpy or pandas. connect(use_kernel=True) doesn't either, and building the first result set does import pyarrow. With pyarrow made unimportable, the three modules' pyarrow is None. The stand-in resolves to the real classes and hands back the real module once pyarrow is imported. The full unit suite, the realkernel sets and mypy pass.

Local mock server adding 20 ms per request, 8 cores, kernel main, connector #1049 + #1051 with and without this change, fresh processes, interleaved:

before this PR
import databricks.sql.client (#1049 only), median of 5 196 ms 124 ms
same, site-packages without .pyc 519 ms 311 ms
1 process × 200 threads connect(use_kernel=True) + 1 query, 3 runs: connect() p50 264 / 267 / 278 ms 202 / 220 / 266 ms
same, all sessions open 0.37 / 0.38 / 0.40 s 0.28 / 0.30 / 0.33 s
same, first result p50 0.62 / 0.62 / 0.63 s 0.63 / 0.64 / 0.69 s
4 processes × 200 sessions × 10 queries (c800), 6 runs: all sessions open, median (max) 1.00 s (3.12 s) 0.81 s (0.95 s)
same, each session's open time from process launch (t0), p50 / p99, median of runs 0.64 / 0.98 s 0.49 / 0.80 s
same, first query done, median 1.04 s 0.97 s

In the single process, the first result arrives at about the same time or a little later, because pyarrow's import moves there. With four processes starting together, the first query finishes earlier, because numpy's spinning threads no longer overlap the connect burst. The c800 open-time row is measured from process launch, using each session's open timestamp, not c800's own connect() percentiles. c800 starts its threads in a loop, and the import's GIL use delays that loop, so before this change half its threads started connect() 0.3 s or more late and their timings missed that wait.

Steady state, vs #1049 alone (isolated run, c800 4 × 200, 20 ms mock, 60 s, 16 cores, medians of 2 rounds): QPS 5548 → 5582, CPU per query 1.429 → 1.434 ms, each session's open time from process launch p50 1.68 → 1.63 s, all sessions open 1.98 → 1.88 s. Before the rebinding, steady QPS was 2% lower.

Live, on a Reyden warehouse (1 process × 200 threads + 1 query, 8 cores, 5 interleaved runs, a kernel with #453/#455 in both arms), main vs. #1049 + #1051 + this PR together: connect() p50 (from a start barrier) 688 → 368 ms, all sessions open 0.80 → 0.43 s, first result p50 0.83 → 0.77 s, medians of runs.

Live benchmark (staging Large Reyden warehouse)

TPC-H Q6 on a Large Real-Time warehouse, 800 sessions (4 processes x 200 threads), cold start, each session querying back to back for 120 s, 16 client cores. Isolated A/B: only this change vs #1049 c193711 (this PR is stacked on it), 3 interleaved rounds, nothing else on the warehouse. Measured at 517c4c8. Median (min-max):

#1049 (base of this stack) this PR
Average QPS (setup included) 2231 (2150-2266) 2185 (2157-2218)
Steady QPS (after all sessions open) 2265 (2185-2301) 2218 (2190-2252)
Query latency p50 (ms) 344 (338-354) 350 (346-355)
Client CPU per query (ms) 1.18 (1.17-1.23) 1.19 (1.17-1.22)
Session open p50, from process start (s) 1.74 (1.61-1.79) 1.62 (1.59-1.72)
All 800 sessions open (s) 2.20 (2.19-2.26) 2.18 (2.17-2.20)

QPS within noise; small cold-start connect gain.

Related Tickets & Documents

RE-17633 (connection-creation time at high concurrency).

This pull request and its description were written by Isaac.

vuanhphung and others added 2 commits November 13, 2026 18:55
client.py and result_set.py imported pandas at module load, so the first
connect() in a process spent ~165 ms importing it under the import lock
while every other connecting thread waited. pandas is only used when an
Arrow table is converted to rows, so import it there.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
…sing

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good: one low-severity concern. The lazy-import design holds up, test patch points aren't broken by moving the kernel imports inside functions, and the subprocess tests use distinct exit codes so a crash can't pass by accident. The concern is that on a broken pyarrow install, the modules using the new stand-in (client, utils, result_set) and the Thrift/SEA modules that still use try/except disagree on whether pyarrow is available.

The kernel backend re-raises a missing databricks_sql_kernel as an
ImportError, so look for the missing extra along the exception chain.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good, with one low test-robustness issue. The _LazyPyarrow stand-in keeps pyarrow is None working, pyarrow.ipc and pyarrow.types resolve after import pyarrow, and I found no test that patches the KernelResultSet or bind_tspark_params names the PR moved, so moving them breaks nothing.

Comment thread tests/unit/test_lazy_pyarrow_import.py Resolved
… time

Build the test table from IPC bytes, since constructing it from Python
lists makes pyarrow import pandas itself.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
vuanhphung and others added 2 commits November 13, 2026 21:28
Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good. There is one low-severity concern: the lazy stand-in caches attributes, so later patches to the real module don't reach these three modules. The rest checks out: the find_spec gating keeps the pyarrow is None semantics, all three modules use from __future__ import annotations so annotations don't trigger the import, and the subprocess tests use the sentinel exit codes the repo prefers.

client, utils and result_set kept the stand-in as their pyarrow global
after the first result, so every pyarrow.X on the query path went through
a ModuleType subclass's slower attribute lookup. The first use now replaces
the stand-in with the real module in each module that registered its
globals.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good, with one low concern: when pyarrow is installed but can't be imported, the import is retried on every attribute access. The lazy stand-in keeps pyarrow is None working, rebinds the module globals after first use, and is covered by sentinel-exit subprocess tests. The kernel client's deferred KernelResultSet and bind_tspark_params imports run inside the existing try/except blocks, and no tests patch the moved names.

When pyarrow is installed but can't be imported, every attribute read on
the stand-in retried the import, rerunning pyarrow's and numpy's partial
initialization. The first ImportError is now kept and later reads raise
from it, as the eager import ran it once.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No issues identified by the review bot.

The stand-in copied each resolved attribute onto itself, so a later patch
of pyarrow (mock.patch, monkeypatch, shims) didn't reach code reading
through it. It now keeps no copies and always delegates to the imported
module; the hot path reads the real module directly since the rebinding.

Co-authored-by: Isaac <no-reply@databricks.com>
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No issues identified by the review bot.

This branch was previously deployed

1 inactive deployment
azure-prod — 9770a691 Deployed Nov 14, 2026 by vuanhphung via followup #2282
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant